Scanning .NET Target Frameworks with PowerShell

PowerShell Script Wallpaper

If you’ve ever inherited a large .NET codebase — or simply let one grow organically over the years — you know the feeling: dozens of .csproj files scattered across nested folders, each potentially targeting a different .NET Framework version. Some are on v4.5, others on v4.5.2, a few upgraded to v4.8, and maybe one relic still sitting on v3.5.

Before you can plan a migration, enforce consistency, or even just answer “what are we running?”, you need a clear inventory. Clicking through Visual Studio project properties one by one is not an option when you have 70+ projects.

This article walks through csprojver.ps1, a PowerShell script that solves this problem. It recursively scans a folder for .csproj files, extracts the target framework version from each one, and presents the results as both a formatted table and a visual directory tree. Optionally, it can export everything to a Markdown file — ready to drop into a wiki, a pull request description, or a migration planning document.

The Problem

A .csproj file declares its target framework in one of three ways, depending on the project style:

Legacy-style projects (pre-.NET Core) use TargetFrameworkVersion inside a PropertyGroup:

<PropertyGroup>
  <TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
</PropertyGroup>

SDK-style projects (.NET Core / .NET 5+) use TargetFramework:

<PropertyGroup>
  <TargetFramework>net8.0</TargetFramework>
</PropertyGroup>

Multi-targeting SDK-style projects use TargetFrameworks (plural):

<PropertyGroup>
  <TargetFrameworks>net6.0;net8.0</TargetFrameworks>
</PropertyGroup>

The script needs to handle all three variants and present a unified view.

Script Breakdown

Let’s walk through the script section by section.

1. Parameters

param(
    [Parameter(Mandatory = $true, Position = 0)]
    [string]$Path,

    [Parameter(Mandatory = $false)]
    [string]$Markdown
)

The script takes two parameters:

  • -Path (required): the root folder to scan. Since it’s positional, you can also pass it without the flag name.
  • -Markdown (optional): a file path for Markdown export. When provided, the script writes a .md file containing a project table and a directory tree.

2. Box-Drawing Characters

$ELBOW  = [char]0x2514 + [char]0x2500 + [char]0x2500 + " "  # └──
$TEE    = [char]0x251C + [char]0x2500 + [char]0x2500 + " "  # ├──
$PIPE   = [char]0x2502 + "   "                                # │

These Unicode box-drawing characters are used to render the directory tree. They are defined via hex code points rather than literal characters to avoid encoding issues — PowerShell on Windows can misinterpret UTF-8 files without a BOM (Byte Order Mark), so constructing them at runtime is the safest approach.

3. Collecting .csproj Data

$csprojFiles = Get-ChildItem -Path $Path -Filter *.csproj -Recurse -File

Get-ChildItem with -Recurse walks the entire directory tree. The -Filter *.csproj flag restricts results to project files only, and -File excludes directories.

4. Parsing Each Project File

$projects = foreach ($file in $csprojFiles) {
    $xml = Get-Content $file.FullName -Raw

    $framework = $null

    foreach ($pg in $xml.Project.PropertyGroup) {
        if ($pg.TargetFramework) {
            $framework = $pg.TargetFramework
            break
        }
        if ($pg.TargetFrameworks) {
            $framework = $pg.TargetFrameworks
            break
        }
        if ($pg.TargetFrameworkVersion) {
            $framework = $pg.TargetFrameworkVersion
            break
        }
    }

    if (-not $framework) {
        $framework = "(not found)"
    }

    [PSCustomObject]@{
        Project          = $file.Name
        TargetFramework  = $framework.Trim()
        RelativePath     = $file.FullName.Substring($Path.Length).TrimStart('\', '/')
        FullPath         = $file.FullName
        Directory        = $file.DirectoryName
    }
}

This is the core extraction logic. For each .csproj file:

  1. Load as XML casts the file content into an XML document, giving us dot-notation access to elements.
  2. Iterate over PropertyGroup nodes — A .csproj can have multiple PropertyGroup elements (e.g., one per build configuration). The script checks each one until it finds a framework declaration.
  3. Check all three variantsTargetFramework, TargetFrameworks, and TargetFrameworkVersion are checked in order. The first match wins.
  4. Fallback — If none of the three properties exist (e.g., a SQL CLR project with a non-standard structure), the framework is reported as (not found).
  5. Build a result object — Each project becomes a PSCustomObject with the project name, framework, relative path, full path, and parent directory. This structured object is what powers both the table and the tree later.

The results are then sorted alphabetically by relative path:

$projects = @($projects | Sort-Object RelativePath)

The @() wrapper ensures the result is always an array, even if only one project is found — this prevents issues with .Count and iteration later.

5. Displaying the Table

$maxNameLen = ($projects | ForEach-Object { $_.Project.Length } | Measure-Object -Maximum).Maximum
$maxFwLen   = ($projects | ForEach-Object { $_.TargetFramework.Length } | Measure-Object -Maximum).Maximum

$nameCol = [Math]::Max($maxNameLen, 7)
$fwCol   = [Math]::Max($maxFwLen, 16)

$headerFmt = "{0,-$nameCol}  {1,-$fwCol}  {2}"

Rather than using a fixed-width layout, the script measures the longest project name and framework string, then builds a dynamic format string. [Math]::Max ensures columns are never narrower than their header text (“Project” = 7 chars, “TargetFramework” = 16 chars).

Each row is then printed with colour coding:

foreach ($p in $projects) {
    $color = if ($p.TargetFramework -eq "(not found)") { "Yellow" } else { "Green" }
    $line = $headerFmt -f $p.Project, $p.TargetFramework, $p.RelativePath
    Write-Host $line -ForegroundColor $color
}

Projects with a detected framework appear in green; those where the framework couldn’t be found appear in yellow as a visual warning.

6. Building the Directory Tree

The tree is the most involved part of the script. It is built by the Build-Tree function, which takes the root path, the list of project objects, and the box-drawing characters as input.

Step 1 — Build a directory lookup

$dirLookup = @{}
foreach ($p in $Projects) {
    $dir = $p.Directory
    if (-not $dirLookup.ContainsKey($dir)) {
        $dirLookup[$dir] = @()
    }
    $dirLookup[$dir] += $p
}

This creates a hashtable mapping each directory path to the list of .csproj files it contains. This allows O(1) lookups when rendering each tree node.

Step 2 — Identify relevant directories

$allDirs = @{}
foreach ($dir in $dirLookup.Keys) {
    $current = $dir
    while ($current -and $current.Length -ge $RootPath.Length) {
        $allDirs[$current] = $true
        $current = Split-Path $current -Parent
    }
}

Not every directory in the folder tree contains a .csproj file, but the tree still needs to show ancestor directories to maintain the visual hierarchy. This loop walks upward from each project directory to the root, marking every directory along the way as “relevant”. Only these directories will appear in the tree — everything else is pruned.

Step 3 — Recursive rendering

The inner Write-TreeNode function recurses through the relevant directories:

function Write-TreeNode {
    param(
        [string]$CurrentPath,
        [string]$Prefix,
        [bool]$IsLast,
        [System.Collections.Generic.List[string]]$Lines,
        ...
    )

Key design decisions:

  • $Lines is a List[string], not a PowerShell array. PowerShell arrays are immutable — += creates a new array each time, which is both slow and incompatible with nested function scoping. A .NET List[string] is mutable and passed by reference, so the recursive function can append lines directly.
  • All shared state is passed explicitly as parameters. PowerShell’s nested functions don’t reliably close over variables from their parent scope, especially hashtables. Passing $DirLookup, $AllDirs, and the box-drawing characters as parameters avoids subtle scoping bugs.
  • $Prefix accumulates as the recursion deepens. Each level adds either a pipe () or whitespace ( ), depending on whether the parent node was the last child. This is how the tree connectors stay aligned across depth levels.
  • .csproj files are listed before subdirectories within each folder, making it easy to spot the project file among its sibling folders.

Each .csproj file is rendered with its framework version in square brackets:

├── Contoso.Core.csproj [v4.5.2]

7. Markdown Export

if ($Markdown) {
    $sb = [System.Text.StringBuilder]::new()

    [void]$sb.AppendLine("# .csproj Target Framework Report")
    ...

When the -Markdown parameter is provided, the script builds a Markdown document using a StringBuilder (more efficient than string concatenation in a loop). The output contains:

  • A heading with the root path and scan timestamp.
  • A Markdown table with columns for project name, target framework (in inline code), and relative path.
  • A fenced code block containing the directory tree, preserving the box-drawing characters.

The file is written with UTF-8 encoding:

$sb.ToString() | Out-File -FilePath $Markdown -Encoding utf8

How to Use It

Basic scan — console output only

.\csprojver.ps1 -Path C:\Projects\MySolution

Or using the positional parameter:

.\csprojver.ps1 C:\Projects\MySolution

This prints the table and tree directly to the console with colour coding.

Scan a subfolder

.\csprojver.ps1 -Path .\src\Services

Useful when you only care about a specific area of the codebase (e.g., backend services, shared libraries).

Export to Markdown

.\csprojver.ps1 -Path . -Markdown framework-report.md

This produces both the console output and a framework-report.md file. The Markdown file contains a table like:

Project Target Framework Path
Contoso.Core.csproj v4.5.2 src/Services/Contoso.Core/Contoso.Core.csproj
Contoso.Api.csproj net8.0 src/Services/Contoso.Api/Contoso.Api.csproj

And a tree like:

src/
├── Services/
│   ├── Contoso.Core/
│   │   └── Contoso.Core.csproj [v4.5.2]
│   ├── Contoso.Api/
│   │   └── Contoso.Api.csproj [net8.0]
│   └── Contoso.Data/
│       └── Contoso.Data.csproj [v4.5.2]
└── Tests/
    └── Contoso.Core.Tests/
        └── Contoso.Core.Tests.csproj [v4.8]

Execution policy

If you get a “script is not digitally signed” error, run:

.\csprojver.ps1 -Path .
# If blocked, use:
powershell -ExecutionPolicy Bypass -File csprojver.ps1 -Path .

Or set the policy for your current session:

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\csprojver.ps1 -Path .

Sample Output

Running against a large enterprise solution produces output like:

=== .csproj Target Framework Report ===
Root: C:\Projects\MySolution

Project                                   TargetFramework   Path
---------------------------------------------------------------------------
Contoso.Core.csproj                       v4.5.2            src\Services\Contoso.Core\...
Contoso.Api.csproj                        net8.0            src\Services\Contoso.Api\...
Contoso.Data.csproj                       v4.5.2            src\Services\Contoso.Data\...
Contoso.Core.Tests.csproj                 v4.8              src\Tests\Contoso.Core.Tests\...
Contoso.Legacy.csproj                     v3.5              src\Legacy\Contoso.Legacy\...
Contoso.SqlClr.csproj                     (not found)       sql\Contoso.SqlClr\...

Total: 6 project(s)

At a glance you can see the framework spread: some projects on v4.5.2, one modernised to net8.0, tests upgraded to v4.8, and a legacy project still on v3.5. The yellow (not found) entry flags a project worth investigating manually.

Wrapping Up

csprojver.ps1 is a single-file, dependency-free PowerShell script that gives you instant visibility into the target framework versions across an entire .NET solution tree. Whether you’re planning a framework migration, auditing for EOL runtimes, or just trying to understand what you’re working with, having this information in a table and a tree — and optionally in a Markdown file — saves time and removes guesswork.

Related posts

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.